/** * Copyright (C) 2014 cherimojava (http://github.com/cherimojava/orchidae) Licensed under the Apache License, Version * 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the * License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the specific language governing permissions and limitations * under the License. */ package com.github.cherimojava.orchidae.controller; import static com.github.cherimojava.orchidae.util.FileUtil.generateId; import static java.lang.String.format; import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; import java.util.Iterator; import java.util.List; import javax.imageio.ImageIO; import org.apache.commons.io.FileUtils; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.imgscalr.Scalr; import org.joda.time.DateTime; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.InputStreamResource; import org.springframework.core.io.Resource; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PostAuthorize; import org.springframework.security.access.prepost.PostFilter; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartHttpServletRequest; import com.github.cherimojava.data.mongo.entity.EntityFactory; import com.github.cherimojava.data.mongo.query.OngoingQuery; import com.github.cherimojava.data.mongo.query.QuerySort; import com.github.cherimojava.data.mongo.query.QueryStart; import com.github.cherimojava.orchidae.api.entities.Access; import com.github.cherimojava.orchidae.api.entities.BatchUpload; import com.github.cherimojava.orchidae.api.entities.Picture; import com.github.cherimojava.orchidae.api.entities.User; import com.github.cherimojava.orchidae.api.hook.UploadHook; import com.github.cherimojava.orchidae.controller.api.UploadResponse; import com.github.cherimojava.orchidae.hook.HookHandler; import com.github.cherimojava.orchidae.util.FileUtil; import com.github.cherimojava.orchidae.util.UserUtil; import com.google.common.base.Joiner; import com.google.common.collect.Lists; import com.mongodb.client.MongoCursor; /** * Does the handling of uploading and serving pictures * * @author philnate */ @RestController @RequestMapping( value = "/picture" ) public class PictureController { /** * pattern for picture id */ public static final String PICTURE_ID = "/{id:[a-f0-9]+}"; /** * URI pattern for pictures */ public static final String PICTURE_URI = "/{user}" + PICTURE_ID; @Value( "${limit.latestPictures:30}" ) int latestPictureLimit; @Value( "${picture.small.maxHeight:300}" ) int maxHeight; @Autowired EntityFactory factory; @Autowired FileUtil fileUtil; @Autowired UserUtil userUtil; @Autowired protected HookHandler hookHandler; /** * identifier on clientside for batch */ protected static final String BATCH_IDENTIFIER = "batch"; private static final Logger LOG = LogManager.getLogger(); /** * Returns a list (json) with the {number} most recent photos of the given {user}. * * @param user to retrieve pictures from * @param number (optional) number of pictures to ask for. Number is constrained by {@link #latestPictureLimit} * @param skip number of pictures to skip. Must be non negativ * @return picture json list with the latest pictures * @since 1.0.0 */ @RequestMapping( value = "/{user}/_latest", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE ) @PostFilter( "@paa.hasAccess(filterObject.id)" ) public List<Picture> latestPicturesMetaByUserLimit( @PathVariable( "user" ) String user, @RequestParam( value = "n", required = false ) Integer number, @RequestParam( value = "s", required = false ) Integer skip) { if ( number == null || number > latestPictureLimit ) { LOG.info( "latest picture request was ({}) greater than max allowed {}. Only returning max", number, latestPictureLimit ); number = latestPictureLimit; } QueryStart<Picture> query = factory.query( Picture.class ); QuerySort<Picture> querySort = query// .where( query.e().getUser().getUsername() ).is( user )// .and( query.e().isDeleted() ).is( false )// .limit( number ).sort().desc( query.e().getOrder() ); if ( skip != null && skip > 0 ) { querySort.skip( skip ); } return Lists.newArrayList( querySort.iterator() ); } @ResponseBody @RequestMapping( value = PICTURE_URI + "/_next", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE ) @PreAuthorize( "@paa.hasAccess(#id)" ) @PostAuthorize( "@paa.hasAccess(returnObject)" ) public ResponseEntity<Picture> getNext( @PathVariable( "user" ) String user, @PathVariable( "id" ) String id) { return getAdjacentPicture( user, id, false ); } @ResponseBody @RequestMapping( value = PICTURE_URI + "/_previous", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE ) @PreAuthorize( "@paa.hasAccess(#id)" ) @PostAuthorize( "@paa.hasAccess(returnObject)" ) public ResponseEntity<Picture> getPrevious( @PathVariable( "user" ) String user, @PathVariable( "id" ) String id) { return getAdjacentPicture( user, id, true ); } /** * retrieves the picture adjacent to the given order number. * * @param user for which the lookup is performed * @param id the order number to find its ancestor or successor * @param previous should the ancestor or successor be found * @return null if no adjacent picture was found for the given order number or the picture found */ protected ResponseEntity<Picture> getAdjacentPicture( String user, String id, boolean previous ) { Picture pic = factory.load( Picture.class, id ); if ( pic != null ) { QueryStart<Picture> query = factory.query( Picture.class ); OngoingQuery<Picture> oquery = query.where( query.e().getUser().getUsername() ).is( user );// oquery.and( query.e().isDeleted() ).is( false ); if ( previous ) { oquery.and( query.e().getOrder() ).lessThan( pic.getOrder() ); } else { oquery.and( query.e().getOrder() ).greaterThan( pic.getOrder() ); } MongoCursor<Picture> it = oquery.sort() .by( previous ? QuerySort.Sort.DESC : QuerySort.Sort.ASC, query.e().getOrder() ).limit( 1 ).iterator(); if ( it.hasNext() ) { return new ResponseEntity<>( it.next(), HttpStatus.OK ); } } return new ResponseEntity<>( HttpStatus.NOT_FOUND ); } /** * returns the total number of pictures for this user according to the permissions of the requester. E.g. the owner * will get private pictures included, while everyone else doesn't * * @param user to retrieve the count of pictures from * @return * @since 1.0.0 */ @RequestMapping( value = "/{user}/_count", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE ) public ResponseEntity<String> latestPicturesMetaByUserLimit( @PathVariable( "user" ) String user) { QueryStart<Picture> query = factory.query( Picture.class ); OngoingQuery<Picture> oquery = query.where( query.e().getUser().getUsername() ).is( user ); oquery.and( query.e().isDeleted() ).is( false ); String curUser = UserUtil.getLoggedInUser(); if ( !StringUtils.equals( curUser, user ) ) { oquery.and( query.e().getAccess() ).is( Access.PUBLIC ); } return new ResponseEntity<>( format( "{\"count\":%d}", oquery.count() ), HttpStatus.OK ); } /** * serves the requested picture {id} and size {f} for the given {user} * * @param user user to lookup picture * @param id id of the picture to load * @param format the format/Size of the picture to return * @return the picture which belongs to the given id, or {@link org.springframework.http.HttpStatus#NOT_FOUND} if no * such picture exists * @throws IOException * @since 1.0.0 * @see {@link #_getPicture(String, String)} */ @ResponseBody @RequestMapping( value = PICTURE_URI, method = RequestMethod.GET, produces = MediaType.IMAGE_JPEG_VALUE, params = "f" ) @PreAuthorize( "@paa.hasAccess(#id)" ) public ResponseEntity<Resource> getPicture( @PathVariable( "user" ) String user, @PathVariable( "id" ) String id, @RequestParam( value = "f" ) String format) throws IOException { ResponseEntity resp; switch ( format ) { case "s":// small image return _getPicture( id, "_s" ); case "o":// Original return _getPicture( id, "" ); default:// All unknown garbage return new ResponseEntity<>( HttpStatus.NOT_FOUND ); } } /** * serves the requested picture {id} metadata for the given {user} * * @param user user to lookup picture * @param id picture id to lookup * @return picture metadata */ @ResponseBody @RequestMapping( value = PICTURE_URI, method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE, params = "!f" ) @PreAuthorize( "@paa.hasAccess(#id)" ) public ResponseEntity<Picture> getPictureMeta( @PathVariable( "user" ) String user, @PathVariable( "id" ) String id) { Picture pic = factory.load( Picture.class, id ); return ( pic != null && !pic.isDeleted() ) ? new ResponseEntity<>( pic, HttpStatus.OK ) : new ResponseEntity<>( HttpStatus.NOT_FOUND ); } /** * deletes the given picture {id} and all related data for the given {user} * * @param user user to lookup picture * @param id picture to delete * @return appropriate HTTP code */ @ResponseBody @RequestMapping( value = PICTURE_URI, method = RequestMethod.DELETE, produces = MediaType.APPLICATION_JSON_VALUE ) @PreAuthorize( "@paa.canDelete(#id)" ) public ResponseEntity<String> deletePicture( @PathVariable( "user" ) String user, @PathVariable( "id" ) String id) { Picture pic = factory.load( Picture.class, id ); if ( pic != null && !pic.isDeleted() ) { // the actual cleanup should be triggered through some cronjob or manually // File f = fileUtil.getFileHandle(pic.getId()); // f.delete(); // new File(f.getAbsolutePath() + "_s").delete(); pic.setDeleted( true ).save(); } else { return new ResponseEntity<>( HttpStatus.NOT_FOUND ); } return new ResponseEntity<>( HttpStatus.OK ); } /** * actual method retrieving the picture from disk. Requested picture is only returned if the current user is allowed * to view it * * @param id identification of picture * @param type type of the picture eg _t for thumbnail etc. * @return ResponseEntity containing the resource to the picture or NOT_FOUND * @throws IOException */ private ResponseEntity<Resource> _getPicture( String id, String type ) throws IOException { File picture = fileUtil.getFileHandle( id + type ); if ( picture.exists() ) { return new ResponseEntity<>( new InputStreamResource( FileUtils.openInputStream( picture ) ), HttpStatus.OK ); } else { LOG.debug( "Could not find picture with id {}", id ); // picture doesn't exist so return 404 return new ResponseEntity<>( HttpStatus.NOT_FOUND ); } } /** * uploads multiple files into the system for the current user * * @param request request with pictures to store * @return {@link org.springframework.http.HttpStatus#CREATED} if the upload was successful or * {@link org.springframework.http.HttpStatus#OK} if some of the pictures couldn't be uploaded together with * information which pictures couldn't be uploaded * @since 1.0.0 */ @RequestMapping( method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.MULTIPART_FORM_DATA_VALUE ) public ResponseEntity<UploadResponse> upload( MultipartHttpServletRequest request, @RequestParam( value = "batch", required = false ) String batchId) { List<String> badFiles = Lists.newArrayList(); UploadResponse response = factory.create( UploadResponse.class ); User user = userUtil.getUser( (String) SecurityContextHolder.getContext().getAuthentication().getPrincipal() ); for ( Iterator<String> it = request.getFileNames(); it.hasNext(); ) { MultipartFile file = request.getFile( it.next() ); String type = StringUtils.substringAfterLast( file.getOriginalFilename(), "." ); try { // Create uuid and Picture entity Picture picture = EntityFactory.instantiate( Picture.class ); picture.setId( generateId() ); // save picture File storedPicture = fileUtil.getFileHandle( picture.getId() ); file.transferTo( storedPicture ); BufferedImage image = ImageIO.read( FileUtils.openInputStream( storedPicture ) ); // Call all hooks UploadHook.UploadInfo ui = new UploadHook.UploadInfo(); ui.pictureUploaded = picture; ui.uploadedFile = file; ui.uploadingUser = user; ui.storedImage = image; hookHandler.callHook( UploadHook.class ).callAll().upload( ui ); // todo, would be good if this could be moved into hook as well createSmall( picture.getId(), image, type ); checkBatch( picture, batchId ); // save picture factory.save( picture ); LOG.info( "Uploaded {} and assigned id {}", file.getOriginalFilename(), picture.getId() ); // after the picture is saved we can add it to the response response.addIds( picture.getId() ); } catch ( Exception e ) { LOG.warn( "failed to store picture", e ); badFiles.add( file.getOriginalFilename() ); } } user.save();// We should persist this information? Or should we rely on the persistence magic? if ( badFiles.isEmpty() ) { return new ResponseEntity<>( response, HttpStatus.CREATED ); } else { response.setIds( Lists.<String> newArrayList() ); return new ResponseEntity<>( response.setMsg( "Could not upload all files. Failed to upload: " + Joiner.on( "," ).join( badFiles ) ), HttpStatus.OK ); } } /** * check if batching should be applied to the current picture upload * * @param pic * @param batchId */ private void checkBatch( Picture pic, String batchId ) { if ( StringUtils.isNotEmpty( batchId ) ) { if ( !FileUtil.validateId( batchId ) ) { // ignore the batching if the id isn't valid return; } BatchUpload batch = factory.load( BatchUpload.class, batchId ); // if the batch doesn't exist, create it if ( batch == null ) { batch = factory.create( BatchUpload.class ); batch.setUploadDate( DateTime.now() ).setId( batchId ); } batch.addPictures( pic ); pic.setBatchUpload( batch ); batch.save(); } } /** * creates the Thumbnail for the given picture and stores it on the disk * * @param id * @param image * @param type */ private void createSmall( String id, BufferedImage image, String type ) { int height = image.getHeight(); int width = image.getWidth(); double scale = maxHeight / (double) height; BufferedImage thumbnail = Scalr.resize( image, Scalr.Method.ULTRA_QUALITY, ( (Double) ( width * scale ) ).intValue(), ( (Double) ( height * scale ) ).intValue(), Scalr.OP_ANTIALIAS ); try { ImageIO.write( thumbnail, type, fileUtil.getFileHandle( id + "_s" ) ); } catch ( IOException e ) { LOG.error( "failed to create thumbnail for picture", e ); } } }